Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Keycloak modules retry request on authentication error, support refresh token parameter #9494

Merged
merged 42 commits into from
Jan 26, 2025

Conversation

armkeh
Copy link
Contributor

@armkeh armkeh commented Dec 30, 2024

SUMMARY

Fixes #8857.

Wraps all requests to Keycloak in the Keycloak modules (keycloak_authentication, keycloak_authz_authorization_scope, keycloak_authz_custom_policy, etc.) with retry logic to make use of a new refresh_token module parameter.

This improves the user experience when using Keycloak modules with the auth_token parameter; previously if that token expired during playbook execution, subsequent tasks would fail. Now they "fall back" to using the refresh_token, or, if it is not provided or is expired itself, to using the auth_username and auth_password.

    def _request(self, url, method, data=None):
        def make_request_ignoring_401():
            try:
                return open_url(url, method=method, data=data,
                                http_agent=self.http_agent, headers=self.restheaders,
                                timeout=self.connection_timeout,
                                validate_certs=self.validate_certs)
            except HTTPError as e:
                if e.code != 401:
                    raise e

            return None

        r = make_request_ignoring_401()
        if r is not None:
            return r

        # Authentication may have expired, re-authenticate with refresh token and retry
        refresh_token = self.module.params.get('refresh_token')
        if refresh_token is not None:
            token = _get_token_using_refresh_token(self.module.params)
            self.restheaders['Authorization'] = 'Bearer ' + token

        r = make_request_ignoring_401()
        if r is not None:
            return r

        # Retry once more with username and password
        auth_username = self.module.params.get('auth_username')
        auth_password = self.module.params.get('auth_password')
        if auth_username is not None and auth_password is not None:
            token = _get_token_using_credentials(self.module.params)
            self.restheaders['Authorization'] = 'Bearer ' + token

        return make_request_ignoring_401()
ISSUE TYPE
  • Feature Pull Request
COMPONENT NAME

keycloak

Copy link
Collaborator

@russoz russoz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi @armkeh thanks for your contribution!

Couple of comments on the PR.

plugins/modules/keycloak_authentication.py Outdated Show resolved Hide resolved
@felixfontein felixfontein added check-before-release PR will be looked at again shortly before release and merged if possible. backport-10 Automatically create a backport for the stable-10 branch labels Dec 31, 2024
Copy link
Collaborator

@russoz russoz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Collaborator

@felixfontein felixfontein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your contribution! Please add a changelog fragment. Thanks.

plugins/module_utils/identity/keycloak/keycloak.py Outdated Show resolved Hide resolved
Copy link
Collaborator

@russoz russoz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of comments more.

Comment on lines 314 to 329
def _request(self, url, method, data=None):
def make_request_catching_401():
try:
return open_url(url, method=method, data=data,
http_agent=self.http_agent, headers=self.restheaders,
timeout=self.connection_timeout,
validate_certs=self.validate_certs)
except HTTPError as e:
if e.code != 401:
raise e
return e

r = make_request_catching_401()
if not isinstance(r, Exception):
return r

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Little late in the game for that, I know, but I believe this would be leaner as:

Suggested change
def _request(self, url, method, data=None):
def make_request_catching_401():
try:
return open_url(url, method=method, data=data,
http_agent=self.http_agent, headers=self.restheaders,
timeout=self.connection_timeout,
validate_certs=self.validate_certs)
except HTTPError as e:
if e.code != 401:
raise e
return e
r = make_request_catching_401()
if not isinstance(r, Exception):
return r
def _request(self, url, method, data=None):
class Unauthorized(Exception):
pass
def make_request_catching_401():
try:
return open_url(url, method=method, data=data,
http_agent=self.http_agent, headers=self.restheaders,
timeout=self.connection_timeout,
validate_certs=self.validate_certs)
except HTTPError as e:
if e.code != 401:
raise e
raise Unauthorized()
try:
return make_request_catching_401()
exception Unauthorized:
pass
except Exception:
raise

and so on

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this, however we need to keep the original 401 exception in case no re-auth options are available and it needs to be re-raised. (Thanks for catching the mistake where it was being returned and not raised in your other comment.)

Comment on lines 347 to 350
r = make_request_catching_401()

return r

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This last call to make_request_catching_401() is not being verified. If, for whatever reason, the HTTP request inside that call returns a 401, that Exception object will be returned (not raised) and no error handling will be performed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, good catch; I've reworked the retry wrappers to make the flow a little clearer, removing early returns, and made sure to throw the exception if that's the final result.

Copy link
Collaborator

@russoz russoz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one possible improvement and a comment, other than that LGTM

Comment on lines 173 to 191
try:
r = json.loads(to_native(open_url(auth_url, method='POST',
validate_certs=validate_certs, http_agent=http_agent, timeout=connection_timeout,
data=urlencode(payload)).read()))
except ValueError as e:
raise KeycloakError(
'API returned invalid JSON when trying to obtain access token from %s: %s'
% (auth_url, str(e)))
except Exception as e:
raise KeycloakError('Could not obtain access token from %s: %s'
% (auth_url, str(e)), authError=e)

try:
token = r['access_token']
except KeyError:
raise KeycloakError(
'Could not obtain access token from %s' % auth_url)

return token
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be simpler to go:

Suggested change
try:
r = json.loads(to_native(open_url(auth_url, method='POST',
validate_certs=validate_certs, http_agent=http_agent, timeout=connection_timeout,
data=urlencode(payload)).read()))
except ValueError as e:
raise KeycloakError(
'API returned invalid JSON when trying to obtain access token from %s: %s'
% (auth_url, str(e)))
except Exception as e:
raise KeycloakError('Could not obtain access token from %s: %s'
% (auth_url, str(e)), authError=e)
try:
token = r['access_token']
except KeyError:
raise KeycloakError(
'Could not obtain access token from %s' % auth_url)
return token
try:
r = json.loads(to_native(open_url(auth_url, method='POST',
validate_certs=validate_certs, http_agent=http_agent, timeout=connection_timeout,
data=urlencode(payload)).read()))
return r['access_token']
except ValueError as e:
raise KeycloakError(
'API returned invalid JSON when trying to obtain access token from %s: %s'
% (auth_url, str(e)))
except KeyError:
raise KeycloakError(
'Could not obtain access token from %s' % auth_url)
except Exception as e:
raise KeycloakError('Could not obtain access token from %s: %s'
% (auth_url, str(e)), authError=e)

or remove the except KeyError block entirely - the raised error after that is almost the same.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Rather than exclude the KeyError catch, I made it's message clearer about the problem (response did not include access_token).

except HTTPError as e:
if e.code != 401:
raise e
return e
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still bugs me on principle - exceptions are meant to be raised not returned :-) - but let's not hold this PR back because of it. I'll make a note to myself to return to this later and see if I can make it any better.

@felixfontein
Copy link
Collaborator

LGTM as well.

…name/password (ansible-collections#8857)

To emphasize their difference from the `get_token` function,
which either gets the token from the module params
*or* makes a request for it.
@armkeh
Copy link
Contributor Author

armkeh commented Jan 26, 2025

Thank you all for the feedback! I've addressed the comments (except @russoz's regarding the helper function returning an exception, for which a neat refactor isn't obvious), and added docstrings to the functions I've added or significantly reworked; so outside of any other feedback, I think this is ready to go.

One note, there are some integration tests failing for the Keycloak modules; I confirmed those same tests fail on the main branch currently. I'm planning to try and repair them in a separate PR.

@felixfontein felixfontein merged commit af01182 into ansible-collections:main Jan 26, 2025
138 checks passed
Copy link

patchback bot commented Jan 26, 2025

Backport to stable-10: 💚 backport PR created

✅ Backport PR branch: patchback/backports/stable-10/af0118278b5f8ce2f587ee2f5121f43de8ba4b6b/pr-9494

Backported as #9631

🤖 @patchback
I'm built with octomachinery and
my source is open — https://github.com/sanitizers/patchback-github-app.

@felixfontein felixfontein removed the check-before-release PR will be looked at again shortly before release and merged if possible. label Jan 26, 2025
patchback bot pushed a commit that referenced this pull request Jan 26, 2025
…sh token parameter (#9494)

* feat: begin refactor to support refresh token in keycloak modules

* chore: add start of tests for shared token usage

* feat: progress towards supporting refresh token; token introspection not yet working [8857]

* chore: reset to main branch previous state; a different approach is needed [8857]

* feat: add request methods to keycloak class, which will be expanded with retry logic [8857]

* feat: all requests to keycloak use request methods instead of open_url [8857]

* fix: data argument is optional in keycloak request methods [8857]

* feat: add integration test for keycloak module authentication methods [8857]

* chore: refactor get token logic to separate logic using username/pass credentials [8857]

* chore: refactor token request logic further to isolate request logic [8857]

* chore: fix minor lint issues [8857]

* test: add (currently failing) test for request with invalid auth token, valid refresh token [8857]

* chore: allow realm to be provided to role module with refresh_token, without username/pass [8857]

* feat: add retry logic to requests in keycloak module utils [8857]

* chore: rename keycloak module fail_open_url method to fail_request [8857]

* chore: update all keycloak modules to support refresh token param [8857]

* chore: add refresh_token param to keycloak doc_fragments [8857]

* chore: restore dependency between auth_realm and auth_username,auth_password params [8857]

* chore: rearrange module param checks to reduce future pr size [8857]

* chore: remove extra comma [8857]

* chore: update version added for refresh token param [8857]

* chore: add changelog fragment [8857]

* chore: re-add fail_open_url to keycloak module utils for backward compatability [8857]

* fix: do not make a new request to keycloak without reauth when refresh token not provided (#8857)

* fix: only make final auth attempt if username/pass provided, and return exception on failure (#8857)

* fix: make re-auth and retry code more consistent, ensure final exceptions are thrown (#8857)

* test: fix arguments for invalid token, valid refresh token test (#8857)

* feat: catch invalid refresh token errors during re-auth attempt (#8857)

Add test to verify this behaviour works.

* test: improve test coverage, including some unhappy path tests for authentication failures (#8857)

* chore: store auth errors from token request in backwards compatible way (#8857)

* fix: ensure method is still specified for all requests (#8857)

* chore: simplify token request logic (#8857)

* chore: rename functions to request tokens using refresh token or username/password (#8857)

To emphasize their difference from the `get_token` function,
which either gets the token from the module params
*or* makes a request for it.

* doc: add docstrings for new or significantly modified functions (#8857)

* test: repair unit test following change to exception message upon key error during auth request (#8857)

(cherry picked from commit af01182)
@felixfontein
Copy link
Collaborator

@armkeh thanks for your contribution!
@desand01 @russoz thanks for reviewing!

felixfontein pushed a commit that referenced this pull request Jan 26, 2025
…t on authentication error, support refresh token parameter (#9631)

Keycloak modules retry request on authentication error, support refresh token parameter (#9494)

* feat: begin refactor to support refresh token in keycloak modules

* chore: add start of tests for shared token usage

* feat: progress towards supporting refresh token; token introspection not yet working [8857]

* chore: reset to main branch previous state; a different approach is needed [8857]

* feat: add request methods to keycloak class, which will be expanded with retry logic [8857]

* feat: all requests to keycloak use request methods instead of open_url [8857]

* fix: data argument is optional in keycloak request methods [8857]

* feat: add integration test for keycloak module authentication methods [8857]

* chore: refactor get token logic to separate logic using username/pass credentials [8857]

* chore: refactor token request logic further to isolate request logic [8857]

* chore: fix minor lint issues [8857]

* test: add (currently failing) test for request with invalid auth token, valid refresh token [8857]

* chore: allow realm to be provided to role module with refresh_token, without username/pass [8857]

* feat: add retry logic to requests in keycloak module utils [8857]

* chore: rename keycloak module fail_open_url method to fail_request [8857]

* chore: update all keycloak modules to support refresh token param [8857]

* chore: add refresh_token param to keycloak doc_fragments [8857]

* chore: restore dependency between auth_realm and auth_username,auth_password params [8857]

* chore: rearrange module param checks to reduce future pr size [8857]

* chore: remove extra comma [8857]

* chore: update version added for refresh token param [8857]

* chore: add changelog fragment [8857]

* chore: re-add fail_open_url to keycloak module utils for backward compatability [8857]

* fix: do not make a new request to keycloak without reauth when refresh token not provided (#8857)

* fix: only make final auth attempt if username/pass provided, and return exception on failure (#8857)

* fix: make re-auth and retry code more consistent, ensure final exceptions are thrown (#8857)

* test: fix arguments for invalid token, valid refresh token test (#8857)

* feat: catch invalid refresh token errors during re-auth attempt (#8857)

Add test to verify this behaviour works.

* test: improve test coverage, including some unhappy path tests for authentication failures (#8857)

* chore: store auth errors from token request in backwards compatible way (#8857)

* fix: ensure method is still specified for all requests (#8857)

* chore: simplify token request logic (#8857)

* chore: rename functions to request tokens using refresh token or username/password (#8857)

To emphasize their difference from the `get_token` function,
which either gets the token from the module params
*or* makes a request for it.

* doc: add docstrings for new or significantly modified functions (#8857)

* test: repair unit test following change to exception message upon key error during auth request (#8857)

(cherry picked from commit af01182)

Co-authored-by: Mark Armstrong <[email protected]>
netbsd-srcmastr pushed a commit to NetBSD/pkgsrc that referenced this pull request Jan 29, 2025
11.2.0

Major Changes

community.general

- keycloak_* modules - ``refresh_token`` parameter added. When multiple authentication parameters are provided (``token``, ``refresh_token``, and ``auth_username``/``auth_password``), modules will now automatically retry requests upon authentication errors (401), using in order the token, refresh token, and username/password (ansible-collections/community.general#9494).

community.vmware

- vmware_dvswitch_pvlans - The VLAN ID type has been updated to be handled as an integer (ansible-collections/community.vmware#2267).

dellemc.openmanage

- omevv_firmware - This module allows to update firmware of the single host and single cluster.

fortinet.fortios

- Support check_mode on all the configuration modules.

google.cloud

- google_cloud_ops_agents - role submodule removed because it prevents the collection from passing sanity and lint tests

grafana.grafana

- Ability to set custom directory path for *.alloy config files
- Fix 'dict object' has no attribute 'path' when running with --check
- Update grafana template
- add loki bloom support
- grafana.ini yaml syntax
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
backport-10 Automatically create a backport for the stable-10 branch
Projects
None yet
4 participants